
# realtime_coverage.py  — compatibility-safe real-time coverage snapshots
# Works with older/newer coverage.py. Avoids non-portable json_report args.
import coverage, time, threading, json, os, atexit, io, re

class RealTimeCoverage:
    def __init__(self, interval_sec=5, out_csv="cov_timeseries.csv",
                 branch=True, concurrency=("thread",)):
        self.interval = float(interval_sec)
        self.out_csv = out_csv
        self.cov = coverage.Coverage(branch=bool(branch), concurrency=list(concurrency))
        self._t0 = None
        self._stop = threading.Event()
        self._timer = None
        # CSV header
        with open(self.out_csv, "w", encoding="utf-8") as f:
            f.write("t_sec,line_pct,lines_covered,lines_total,branch_pct,branches_covered,branches_total\n")

    def _totals_via_json(self):
        """Try to read totals from JSON report. Returns a dict or None."""
        tmp_json = f"{self.out_csv}.tmp.json"
        j = None
        try:
            # Call with ONLY 'outfile' for broad compatibility.
            self.cov.json_report(outfile=tmp_json)
            with open(tmp_json, "r", encoding="utf-8") as f:
                j = json.load(f)
        except Exception:
            j = None
        finally:
            try:
                os.remove(tmp_json)
            except Exception:
                pass
        if isinstance(j, dict):
            tot = j.get("totals", {}) or {}
            return {
                "line_pct": float(tot.get("percent_covered") or 0.0),
                "lines_cov": int(tot.get("covered_lines") or 0),
                "lines_tot": int(tot.get("num_statements") or 0),
                "branches_tot": int(tot.get("num_branches") or 0),
                "branches_cov": int(tot.get("covered_branches") or 0),
            }
        return None

    def _totals_via_text(self):
        """Fallback: parse text table from coverage.report().
        Returns a dict with line_pct/lines_cov/lines_tot, and branch fields 0 if unknown.
        """
        buf = io.StringIO()
        try:
            line_pct = float(self.cov.report(file=buf))
        except Exception:
            line_pct = 0.0
        table = buf.getvalue()
        lines_cov = 0
        lines_tot = 0
        try:
            for line in table.splitlines()[::-1]:
                s = line.strip()
                if s.startswith("TOTAL") or s.startswith("Total"):
                    nums = [int(x) for x in re.findall(r"\b\d+\b", line)]
                    if len(nums) >= 2:
                        stmts, miss = nums[0], nums[1]
                        lines_tot = stmts
                        lines_cov = max(stmts - miss, 0)
                    break
        except Exception:
            pass
        return {
            "line_pct": line_pct,
            "lines_cov": lines_cov,
            "lines_tot": lines_tot,
            "branches_tot": 0,
            "branches_cov": 0,
        }

    def _schedule_next(self):
        if self._stop.is_set():
            return
        self._timer = threading.Timer(self.interval, self._snapshot_once)
        self._timer.daemon = True
        self._timer.start()

    def _snapshot_once(self):
        if self._stop.is_set():
            return
        t = time.time() - self._t0
        # Persist current state
        try:
            self.cov.save()
        except Exception:
            pass

        totals = self._totals_via_json()
        if totals is None:
            totals = self._totals_via_text()

        branches_tot = totals.get("branches_tot", 0)
        branches_cov = totals.get("branches_cov", 0)
        branch_pct = (branches_cov / branches_tot * 100.0) if branches_tot else 0.0

        with open(self.out_csv, "a", encoding="utf-8") as f:
            f.write(f"{t:.2f},{totals['line_pct']:.2f},{totals['lines_cov']},{totals['lines_tot']},{branch_pct:.2f},{branches_cov},{branches_tot}\n")

        self._schedule_next()

    def start(self):
        self._t0 = time.time()
        atexit.register(self.stop)
        self.cov.start()
        self._snapshot_once()  # take an immediate sample at ~t=0

    def stop(self):
        if self._stop.is_set():
            return
        self._stop.set()
        try:
            if self._timer is not None:
                self._timer.cancel()
        except Exception:
            pass
        try:
            self.cov.stop()
            self.cov.save()
        except Exception:
            pass
